원본 항목: Part 5. 코드 오류 — 3. 부호 정수를 무부호 정수로 타입 변환 오류CWE-195repo 폴더: sd45_signconv/ (빌드·실행은 §3-1)
목표 시간: 1회차 10분 / 2회차 6분 / 3회차 4분-1이 unsigned int로 감기면 4294967295가 된다는 걸 값 비교로 확인하는 데서 그치지 않고, 그 값이 실제 경계 검사를 무력화시키는 순간까지 재현한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
unsigned int len(char *s) {- if (s == NULL) return -1; /* -1 -> 4294967295 */+ if (s == NULL) return 0; /* 무부호에 안전한 0 반환 */ return strnlen(s, BUFSIZE-1); }
에러를 -1로 표시하는 관용구는 반환형이 signed일 때만 안전하다.
반환형이 unsigned인 함수에서 -1은 최댓값으로 감겨버려, “에러”가 “역대 가장 큰 정상값”으로 둔갑한다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 문자열 길이 조회 + 경계 검사
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
Bmemcpy 길이 인자로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
Csize_t 버전으로 확장
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
1. 요구사항서
1-A. 변형 A — 문자열 길이 조회 + 경계 검사
문자열의 길이를 구해 인덱스 접근 범위를 검사하는 데 사용한다.
문자열이 NULL이면 그 어떤 인덱스도 “범위 안”으로 판정되어서는 안 된다.
함수
unsigned int len_bad(const char *s);unsigned int len_good(const char *s);int within_len_bad(unsigned int idx, const char *s);int within_len_good(unsigned int idx, const char *s);
신뢰 경계
값
출처
검증 없이 흘러가는 곳
len_bad()의 반환값(unsigned int)
NULL 에러 표시(-1)
이후 idx < len 같은 경계 검사식의 우변
공격 입력표
입력
Bad 결과
이유
len_bad(NULL)
4294967295(UINT_MAX)
-1이 unsigned int로 변환되며 최댓값으로 감긴다
within_len_bad(999999, NULL)
참(범위 안으로 착각)
999999 < 4294967295이 참이 되어, 원래는 무조건 거부돼야 할 접근이 통과해버린다
같은 상황(Good)
within_len_good(999999, NULL) → 거짓
len_good(NULL)이 0이라 999999 < 0은 항상 거짓(무부호라 음수 자체가 없음에도 검사가 의도대로 작동)
1-B. 변형 B — memcpy 길이 인자로 변형
len_bad의 결과를 memcpy(dst, src, len)의 세 번째 인자로 그대로 넘기는 함수를 만들어, “거대한 길이로 복사를 시도하다 즉시 실패/크래시”하는 것까지 이어서 본다(직접 memcpy를 실행하지 않고 “그 크기를 받아들이는지”만 확인해도 충분하다)
1-C. 변형 C — size_t 버전으로 확장
unsigned int 대신 size_t(64비트 플랫폼에서는 8바이트)로 바꿔, 같은 문제가 자료형이 커져도 동일하게 발생함을 확인한다
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. len_good — Level 1 (1회차, 전부 본다)
먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다.
그다음 Good 을 쓴다.
unsigned int len_bad(const char *s){ /* 1. s == NULL 이면 return (unsigned int)-1 ← 4294967295 (역대 가장 큰 "정상값") * 2. return strnlen(s, BUFSIZE - 1) */}int within_len_bad(unsigned int idx, const char *s){ /* 1. return idx < len_bad(s) */}unsigned int len_good(const char *s){ /* [ ] 1. s == NULL 이면 → return 0 (무부호에 안전한 에러값) */ /* [ ] 2. return (unsigned int)strnlen(s, BUFSIZE - 1) * BUFSIZE 256 */}int within_len_good(unsigned int idx, const char *s){ /* [ ] 3. return idx < len_good(s) ← Bad 와 **같은 식**, 바뀐 건 len 의 에러값 */}
2-B. len_good — Level 2 (2회차, 번호와 제목만 본다)
unsigned int len_good(const char *s){ /* [ ] 1. s == NULL 이면 → return 0 (무부호에 안전한 에러값) */ /* [ ] 2. return (unsigned int)strnlen(s, BUFSIZE - 1) */}int within_len_good(unsigned int idx, const char *s){ /* [ ] 3. return idx < len_good(s) ← Bad 와 **같은 식**, 바뀐 건 len 의 에러값 */}
2-C. len_good — Level 3 (3회차, 백지)
unsigned int len_good(const char *s){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - `strnlen` 의 feature macro (`_DEFAULT_SOURCE`) * - 에러값 설계는 반환형이 unsigned 일 때 `0` */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
return -1을 return (unsigned int)0 - 1로 바꿔서 “고쳤다”고 착각
표현만 바뀌었을 뿐 값은 여전히 UINT_MAX다. 핵심은 “감기지 않는 표현”이 아니라 **“에러를 나타내는 값 자체를 0처럼 안전한 값으로 바꾸는 것”**이다
호출자 쪽에서 (int)len_bad(s) == -1로 검사하려 함
함수가 이미 unsigned int를 반환하기로 계약했는데 호출자가 다시 int로 캐스팅해 검사하는 건 임시방편이다. 함수 시그니처 자체(반환형과 에러값)를 무부호에 안전하게 설계하는 게 근본 해결책이다
”이 값이 인덱스나 길이로 쓰이지 않으니 상관없다”고 넘김
지금 안 쓰여도 나중에 다른 코드가 이 반환값을 경계 검사나 메모리 크기로 재사용할 수 있다. 함수의 반환값 계약은 사용처와 무관하게 항상 안전해야 한다
5. 모범답안 (변형 A)
다 치고 나서 열 것 — signconv.h / signconv.c
#ifndef SIGNCONV_H#define SIGNCONV_Hunsigned int len_bad(const char *s);unsigned int len_good(const char *s);int within_len_bad(unsigned int idx, const char *s);int within_len_good(unsigned int idx, const char *s);#endif
#define _DEFAULT_SOURCE#include <string.h>#include "signconv.h"#define BUFSIZE 256unsigned int len_bad(const char *s){ if (s == NULL) return (unsigned int)-1; /* -1 -> 4294967295 */ return (unsigned int)strnlen(s, BUFSIZE - 1);}unsigned int len_good(const char *s){ if (s == NULL) return 0; /* 무부호에 안전한 0 반환 */ return (unsigned int)strnlen(s, BUFSIZE - 1);}int within_len_bad(unsigned int idx, const char *s){ return idx < len_bad(s);}int within_len_good(unsigned int idx, const char *s){ return idx < len_good(s);}
눈여겨볼 점: within_len_bad와 within_len_good은 완전히 동일한 코드(idx < len(s))다.
버그는 이 비교식이 아니라 비교 대상이 되는 함수의 에러값 설계에 있었다.
같은 로직이라도 그 로직이 딛고 선 함수의 계약이 잘못되면 결과가 뒤집힌다는 걸 보여주는 좋은 예다.